Add user preferences system with pluggable storage adapters#1749
Add user preferences system with pluggable storage adapters#1749Flo0807 wants to merge 48 commits into
Conversation
…ce-system Resolve conflicts combining the unified collapsible sidebar with the cookie-based user preference system: - Sidebar state (open/closed, section expansion) is server-rendered from cookie preferences and persisted via BackpexPreferences. - Adopt the collapsible sidebar's accessibility improvements (focus trap, aria-expanded/aria-controls, motion-safe transitions, CSS breakpoint variable) on top of the preference-driven initial state. - Rebuild priv/static/js bundles from merged sources. - Move the user preference upgrade notes from v0.18 (already released) to the v0.19 upgrade guide.
Preferences previously went straight to the Phoenix session via a single path (`Backpex.Preferences` + `PreferencesController`). That works for a 4KB cookie but falls over for per-user, cross-device, or bulky state (column visibility across dozens of resources, filter saving, ordering). Introduce `Backpex.Preferences.Adapter` with a side-effect-returning `put/4` callback, a longest-prefix `Backpex.Preferences.Router`, a `Backpex.Preferences.Context` struct, and a `Backpex.Preferences.Key` helper that encodes module names as single segments via a secondary `:` separator so `resource.Elixir.MyApp.MyLive.columns` no longer splits into six nested maps. The legacy cookie path becomes `Backpex.Preferences.Adapters.Session`, the default when no adapters are configured — zero-config upgrade for existing apps. `Preferences.put_batch/3` threads the accumulated session through each adapter call so `put_session` effects over the same session key compose correctly; the controller applies them all-or-nothing. A new `persist: [:order, :filters, :columns]` option on `use Backpex.LiveResource` opts the index view into round-tripping ordering, filters, and column visibility through whichever adapter handles `resource.*`. Also rename `BackpexPreferences.cookiePath` to `endpointPath` in the JS hook — no user-facing change unless you call the hook directly.
Drop the "cookie-based" framing, document the adapter/router/identity layer, and add two Ecto recipes — a generic key/value table and a prefix-to-column mapping for apps that already have a user_settings-style table. Also document the `persist:` opt-in for ordering, filters, and columns.
`Backpex.LiveResource.Index` is `@moduledoc false` and ExDoc with `--warnings-as-errors` treats backticked references to hidden modules as errors. The key-reference table doesn't need to name the module — "Index view mount" / "`toggle_column` event" describe the call sites functionally.
There was a problem hiding this comment.
Pull request overview
Adds a unified user-preferences system with pluggable storage adapters, routing preferences by key-prefix, and persisting UI state (theme/sidebar/resource index state) via a single /backpex_preferences endpoint.
Changes:
- Introduces
Backpex.Preferencesdispatcher + adapter behavior, routing, key parsing, and session adapter implementation. - Replaces cookie/localStorage-based UI persistence (theme/sidebar/metrics/columns) with server-backed preferences and a JS persistence hook.
- Adds/updates tests and guides (user preferences guide + v0.19 upgrade notes) and updates demo integration.
Reviewed changes
Copilot reviewed 41 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/support/in_memory_preferences_adapter.ex | Adds ETS-backed adapter for tests to exercise non-session routing. |
| test/router_test.exs | Updates route assertion from cookies endpoint to preferences endpoint. |
| test/preferences_test.exs | Adds unit tests for dispatcher API and session-map compatibility. |
| test/preferences/router_test.exs | Adds router matching tests (specific vs wildcard vs default). |
| test/preferences/key_test.exs | Adds key parsing/matching tests (dot vs colon forms). |
| test/preferences/dispatcher_integration_test.exs | Adds integration coverage for cross-adapter routing and batch behavior. |
| test/preferences/adapters/session_test.exs | Adds session adapter contract tests (get/get_map/put). |
| test/plugs/theme_selector_plug_test.exs | Removes tests for deleted ThemeSelectorPlug. |
| test/controllers/preferences_controller_test.exs | Adds controller tests for single/batch writes and error handling. |
| test/controllers/cookie_controller_test.exs | Removes tests for deleted CookieController. |
| priv/static/js/backpex.esm.js | Compiled JS update: adds preferences hook, moves persistence off localStorage/cookies. |
| priv/static/js/backpex.cjs.js | Compiled JS update mirroring ESM changes. |
| mix.exs | Adds new user-preferences guide to documentation extras. |
| lib/backpex/router.ex | Renames backpex route to /backpex_preferences and adds preferences_path/1. |
| lib/backpex/preferences/router.ex | Adds prefix-router for selecting adapters via longest-prefix strategy. |
| lib/backpex/preferences/key.ex | Adds key parsing/matching helpers (colon separator to avoid module-dot collisions). |
| lib/backpex/preferences/context.ex | Adds context struct/builders for adapter calls + identity memoization. |
| lib/backpex/preferences/adapters/session.ex | Implements session-backed adapter emitting :put_session side effects. |
| lib/backpex/preferences/adapter.ex | Defines adapter behavior + side-effect protocol. |
| lib/backpex/preferences.ex | Adds dispatcher API (get/get_map/put_async/put_batch + identity resolution). |
| lib/backpex/plugs/theme_selector.ex | Deletes ThemeSelectorPlug (theme now handled via preferences/init assigns). |
| lib/backpex/live_resource/index.ex | Adds opt-in persisted index state (persist:) and pushes preference writes via events. |
| lib/backpex/live_resource.ex | Adds persist: option to LiveResource configuration schema. |
| lib/backpex/init_assigns.ex | Populates @current_theme, @sidebar_open, @sidebar_section_states from preferences. |
| lib/backpex/html/resource/resource_index_main.html.heex | Removes old toggle-columns form dependencies (socket/current_url). |
| lib/backpex/html/resource.ex | Converts toggle-columns + metrics toggles to LiveView events instead of controller forms. |
| lib/backpex/html/layout.ex | Adds preferences hook mount point, theme selector uses current_theme, sidebar SSR state attrs. |
| lib/backpex/controllers/preferences_controller.ex | Adds JSON endpoint for preference writes (single and batch). |
| lib/backpex/controllers/cookie_controller.ex | Deletes CookieController. |
| guides/upgrading/v0.19.md | Documents new preferences system + breaking changes/migrations. |
| guides/live_resource/user-preferences.md | New guide describing architecture, keys, adapters, identity resolver, and persist flags. |
| guides/get_started/installation.md | Updates installation examples for new assigns and updated layout usage. |
| demo/lib/demo_web/router.ex | Updates demo pipeline away from removed Backpex.ThemeSelectorPlug. |
| demo/lib/demo_web/plugs/theme_plug.ex | Adds demo plug for assigning theme from preferences. |
| demo/lib/demo_web/components/layouts/admin.html.heex | Updates demo admin layout to pass socket/sidebar_open/current_theme/sidebar_section_states. |
| demo/lib/demo_web/components/layouts.ex | Adds new assigns needed by updated admin layout. |
| demo/assets/js/app.js | Removes now-unneeded setStoredTheme() call. |
| assets/js/hooks/index.js | Exports new BackpexPreferencesHook. |
| assets/js/hooks/_theme_selector.js | Persists theme via preferences (no localStorage + no direct fetch). |
| assets/js/hooks/_sidebar.js | Persists sidebar open/section state via preferences (no localStorage). |
| assets/js/hooks/_preferences.js | Adds preferences persistence module + LiveView hook to receive push_events and POST JSON. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Bundles: B6, M8, M9, m11, m12 (docs); m2, m3, m7, m8, m9, m10 (code hygiene)
- B2: add Router.resolve_prefix/1 for correct get_map/3 adapter selection - B2: raise ArgumentError at config time for subtree-conflicting patterns - M10: cover tie-break, zero-config, malformed entries, deeper exact wins
…ircuit - Switch put_batch/3 loop to reduce_while (B1 Path A) - Prepend + reverse effects accumulator (m1, removes O(n²)) - Walk back "atomic" claim in moduledoc, docstring, controller, guide - Replace toothless atomicity test with short-circuit proof (B8)
…t seam - Backpex.Preferences.Keys exposes canonical key names - Replace string literals in InitAssigns, Index, and tests - Extract push_event seam to Backpex.Preferences.LiveView.push_write/3
D3 resolved to Option P (hard rename, no deprecation). Since the function runs synchronously, "async" was a misnomer. New name mirrors Map.put/3 and Plug.Conn.put_session/3. Call sites in library, tests, and guides updated.
- B3: route {:put_session, _} from socket through push_event fallback; warn
- B9: add put/4 test coverage (Plug.Conn, Socket, adapter raise, unidentified)
- M1: walk back per-session memoization claim in docstring
- M4: add Logger.warning on resolver rescue and error swallow paths
- m4: drop dead conn field from Context struct
- m5: strict coerce/1 guard; raise on non-session shapes
- m6: add fetch/3 distinguishing :error from {:error, reason}; log in get/3
…og attribution
Two review-driven follow-ups on the preference dispatcher.
Fix 1: fetch/3 now collapses {:error, :unidentified} to :error — matching
the adapter behaviour's "treat as not found" semantics. No warning is
logged for that case because it is the expected path for anonymous
visitors / background jobs. Other {:error, reason} tuples still surface
unchanged, so a genuine adapter failure remains distinguishable.
Fix 2: Every dispatcher-originated Logger.warning now carries the adapter
module in the message — "adapter \#{inspect(module)} returned error on
get/3 ..." — so operators running multi-adapter routing (global.* →
Session, resource.* → EctoAdapter) can tell which backend failed. The
dispatch helpers now return {module, result} tuples so callers can
attribute; identity-resolver warnings say "resolving identity via
\#{resolver}" instead, since no adapter is involved.
Restores test coverage for the on_mount hook that replaced the deleted
ThemeSelectorPlug. Covers the happy path, malformed-session fallbacks,
and adapter-driven overrides for `global.theme`.
Also fixes a latent bug in `Backpex.Preferences.Adapters.Session.root/1`
uncovered while writing the malformed-session tests: a host app that
stomps on the session key with a non-map (binary/nil/other) caused
`get_in/2` to crash. `root/1` now coerces any non-map value to `%{}`.
…option Configure DemoWeb.PostLive with persist: [:order, :filters, :columns] and add preferences_persistence_test.exs covering all three persistence kinds. Each test mounts the LiveResource, triggers the relevant interaction (sort, filter change, column toggle), and asserts the matching push_event is emitted with the canonical key built via Backpex.Preferences.Keys. The wire event name comes from Backpex.Preferences.LiveView.event_name/0, so a rename of either breaks the suite. Regression verified: removing the PreferenceLiveView.push_write call from maybe_persist_order/2 makes the order test fail.
- B4: export BackpexPreferences as named export in JS bundle so the custom.* recipe in the guide works when copy-pasted - B5: remove DemoWeb.ThemePlug (replaced by Backpex.InitAssigns); demo now mirrors the upgrade guide's instructions
Strip finding IDs (B1, M6, m2, ...), hypothetical-rename stories, and forward/backward "we used to X, now we Y" narrative from comments and docs. Replace with descriptions of what the code is and what it does.
Backpex.Preferences.Key.validate/1 checks a key against known top-level prefixes (global, resource, custom, plus anything an app adds via config :backpex, Backpex.Preferences.Key, extra_prefixes: [...]). The dispatcher runs validation when config :backpex, Backpex.Preferences, validate_keys: :log | true is set -- default is off so prod logs stay quiet. :log warns and dispatches; true raises ArgumentError, useful in test configs to catch typos mechanically. put_batch/3 is intentionally skipped since it is the cross-adapter dispatch used by the preferences controller. Keys module gains an @after_compile self-check plus a companion test so every built-in helper emits a key that passes validate/1.
Every successful put/4 and put_batch/3 entry broadcasts
{:backpex_preference_changed, %{key, value, source}} on
"<topic_prefix>:<identity>" when config :backpex, Backpex.Preferences,
pubsub: [server: …, topic_prefix: …] is set. Default config emits
nothing — zero cost. Broadcast failures log and do not break writes.
Adds subscribe/1 + unsubscribe/1 helpers and 11 new tests covering
the batch short-circuit, anonymous topic, and broadcast-raises-is-safe
paths.
live_resource_index/3 mounts a LiveResource Index view and transparently
follows the default-filter redirect that Backpex issues on first mount
when the resource has filter presets and no persisted filter state.
Caller gets {:ok, view, html} directly instead of having to pattern-match
on {:error, {:live_redirect, _}} and re-mount.
put_preference/3 seeds a preference in a Plug.Conn for tests that need
a specific persisted state at mount (column visibility, sort order, ...).
Covers 17 tests plus a guide section on testing LiveResources.
Four new references to `Backpex.LiveResource.Index` were reintroduced
(two in the user-preferences guide, two in the Backpex.Test moduledoc)
after the earlier doc-reference cleanup. ExDoc with
--warnings-as-errors treats backticked refs to `@moduledoc false`
modules as errors. Restate each spot in prose ("the index view").
The app_shell component requires socket for preferences_path/1 and uses sidebar_open for the initial render to avoid a first-paint flash.
Previously put_batch bypassed maybe_validate_key/2, so the configured validation strategy (:off, :log, :raise) only applied to single writes. Batch writes from controllers and LiveViews now go through the same gate.
Previously a raising adapter crashed the calling process during batch
writes. Mirror dispatch_put/4's try/rescue/catch so the same exception
becomes {:error, …} and the reduce halts cleanly.
…uter.normalize(nil)
When the resolved identity is nil or :unidentified, every visitor would otherwise share a single "anonymous" topic and receive each other's preference change events. Treat anonymous as opt-out: subscribe and broadcast both no-op, while identified callers are unchanged.
The drawer sets aria-modal="true" but the rest of the page remained focusable, letting keyboard and screen-reader users escape into hidden content. Toggle inert on the main region alongside the drawer state (open/close/resize-to-desktop/teardown).
The function and its route were renamed during the preferences refactor. Forks of the topbar or theme selector that call the old name need an explicit migration breadcrumb.
The route name and its purpose changed during the preferences refactor; the install guide still pointed to the old name and described the route as cookie-specific.
Quokka 2.12.1 crashed formatting empty defmodules containing only comments. Explicit @moduledoc false fixes the crash; Quokka then extracts Adapter and Phoenix.LiveView.Plug aliases.
The dropdown component hardcodes phx-hook="BackpexDropdown" on its root element, so a second phx-hook passed via @rest collided as a duplicate attribute and was dropped by the browser. Moving the theme hook onto the inner form avoids the collision; the listener now binds to the form element directly instead of the window.
The v0.19 upgrade guide documents the cookie_path/1 → preferences_path/1 rename, so the old name must remain in prose. Adding it to skip_code_autolink_to stops mix docs --warnings-as-errors from failing on the now-undefined reference.
Credo's UnusedVariableNames check flags bare `_` because the rest of the codebase uses `_foo`-style names.
When persist: [:filters] restores a preset whose values function returns
typed Elixir terms (e.g. %{"start" => 100, "end" => nil} or a %Date{}),
the value reaches Backpex.Filters.Range.maybe_parse/3 unchanged. The
URL path always feeds binaries via Plug.Conn.Query, so the previous
parse_float_or_int/1 and date?/1 crashed on non-binary input.
Make maybe_parse/3, parse_float_or_int/1, and date?/1 accept integers,
floats, Date structs, and nil in addition to binaries, so presets can
use natural Elixir values without crashing the session-restore path.
Single-write :unidentified is an expected anonymous-visitor state, not a 4xx. Matches fetch/3 / subscribe/1 / PubSub broadcaster, which all already no-op for :unidentified. Batches keep their 422 halt behavior; the carve-out applies only to single-entry writes from the JS hook.
|
We still have a bug: Toggle a column in the columns dropdown (or hide the metric cards), then navigate to another LiveResource via a sidebar link — the column/cards reappear. Reload still works correctly. Cause: Phoenix LiveView freezes the session at WebSocket-connect time. The toggle's push_write writes the cookie via the preferences controller, but the LV socket's session snapshot is not refreshed. On live_redirect, the destination LiveResource's mount/3 reads the stale session and re-renders from the pre-toggle state. |
7f7316c to
02c2f96
Compare
Summary
Introduces a unified preference system that persists UI state (theme, sidebar, column visibility, ordering, filters, custom keys) through a pluggable adapter layer. Preferences are server-rendered from the adapter on every page load — no flicker on first paint, no round-trip before the UI reflects saved state.
Out of the box everything lives in the Phoenix session (zero config). Route any prefix to a per-user database in config:
Architecture
Core modules
Backpex.Preferences.Adapterget/3,get_map/3,put/4.put/4returns side effects, doesn't touchPlug.Conn.Backpex.Preferences.Router:defaultfallback. Config-time validation raises on conflicting subtree routes.Backpex.Preferences.Contextsource,session,assigns,identity).InitAssigns+ Index reads threadsocket.assignsthrough this so identity resolvers seecurrent_scopedirectly.Backpex.Preferences.Key:as a secondary separator so module-name dots stay as one segment.validate/1+known_prefixes/0for opt-in typo detection.Backpex.Preferences.Keystheme/0,sidebar_open/0,columns/1,order/1,filters/1,metrics_visible/1). Self-checked againstKey.validate/1at compile time.Backpex.Preferences.LiveViewpush_write/3emits thebackpex:set_preferenceevent. Event name isevent_name/0.Backpex.Preferences.Sessionpreserve/1+restore/2for round-tripping preferences acrossUserAuth.renew_session/2without hardcoding the session key.Backpex.Preferences.Adapters.SessionBackpex.Testlive_resource_index/3(follows the default-filter redirect transparently) andput_preference/3(seeds a preference before mount).Dispatcher API (
Backpex.Preferences)get/3— read, fall back to:default. Accepts a%Context{}or a bare session map. Logs a warning when an adapter returns an unexpected error.fetch/3— likeget/3but returns{:ok, value} | :error | {:error, reason}. Distinguishes "not found" (including:unidentified) from adapter failure. Use this when deciding whether to apply a default —%{}/[]can be an explicit user choice (e.g. "clear all filters") and must not be overwritten.get_map/3— read a prefix as a nested map.put/4— write from a socket or conn; falls back topush_event/3when the adapter can't write from a LiveView context.put_batch/3— cross-adapter batch writes, best-effort, first-error-wins. On the first adapter error the batch short-circuits and returns{:error, {key, reason}}. Earlier writes may have already committed — callers should treat partial success as possible. Each entry passes through the configuredvalidate_keysstrategy (same asput/4), and adapter exceptions surface as{:error, {key, {:exception, reason}}}rather than crashing the caller.subscribe/1/unsubscribe/1— opt-in Phoenix.PubSub subscriptions for the current user's preference changes (see "Cross-tab sync" below).Router pattern types
"global.theme") and trailing-wildcard ("resource.*") — longest-prefix-first precedence.:default— catch-all fallback.&String.ends_with?(&1, ":columns")) — escape hatch for cross-cutting carve-outs. Ranks most-specific (always beats string patterns); first-in-config-order wins among matches; excluded fromresolve_prefix/2since a function cannot cleanly own a subtree.Identity resolution
One MFA in config (
identity: {Mod, :fun, []}); the dispatcher calls the resolver on every dispatch and caches the result onctx.identityfor the duration of that single call, so each adapter invocation sees a consistent value. The resolver receives a%Backpex.Preferences.Context{}— readctx.assigns.current_scopefirst (freshest, post-auth), fall back toctx.sessiononly for edge cases. If memoization across calls matters, the application should cache externally (e.g. viaon_mountassign). Keep the resolver cheap.Key validation (opt-in)
Backpex.Preferences.Key.validate/1checks a key against known top-level prefixes (global,resource,custom, plus anything an app registers viaconfig :backpex, Backpex.Preferences.Key, extra_prefixes: […]). Wire validation into the dispatcher withconfig :backpex, Backpex.Preferences, validate_keys: :log | true::log(recommended for dev):Logger.warningon unknown-prefix / empty / malformed keys; dispatch still proceeds.true(recommended for test): raisesArgumentErrorso CI catches typos mechanically.The built-in
Keyshelpers self-check at compile time that every canonical key passesvalidate/1.Cross-tab sync (opt-in PubSub)
Every successful
put/4and every successful entry input_batch/3broadcasts{:backpex_preference_changed, %{key, value, source: :controller | :server}}on"<topic_prefix>:<identity>"when configured:Default config emits nothing — zero cost. Broadcast failures (misconfigured server, etc.) are caught, logged, and never break the write. Topics are keyed per-identity. Anonymous callers (
nil/:unidentified) are not broadcast andsubscribe/1is a no-op for them, to prevent cross-user fan-out on the sharedanonymoustopic. LiveViews subscribe withBackpex.Preferences.subscribe(current_user.id)and handle{:backpex_preference_changed, _}inhandle_info/2.Opt-in persistence for index state
New
persist:option onuse Backpex.LiveResource::order— readsresource:<Mod>:orderon mount; writes onhandle_paramswhen order changes.:filters— readsresource:<Mod>:filterson mount; writes onhandle_paramswhen filters change.:columns— readsresource:<Mod>:columnson mount; writes on thetoggle_columnevent.Default is
[]: the URL is the source of truth for order and filters, and column state lives in-memory.Built-in key reference
global.themeInitAssignsglobal.sidebar_openInitAssignsglobal.sidebar_section.<id>InitAssigns(get_map)resource:<Mod>:columnstoggle_columneventpersist: [:columns]resource:<Mod>:metrics_visibletoggle_metricseventresource:<Mod>:orderhandle_params(on change)persist: [:order]resource:<Mod>:filtershandle_params(on change)persist: [:filters]Key encoding
Keys whose segments contain dots (typically because a segment embeds a module name like
MyApp.MyLive) use:as the separator:resource:MyApp.MyLive:columnsparses into three clean segments. Keys without embedded module names use the usual dot form:global.theme.Integration contracts
Spelled out under "Contracts" in the guide. The two that bite hardest:
Attach
Backpex.InitAssignsAFTER your authon_mounthook. The Context built at mount carriessocket.assigns, so identity resolvers can readcurrent_scopedirectly instead of re-implementing the app's session-token lookup.Preserve the preferences session key across
renew_session/2. UseBackpex.Preferences.Session.preserve/1+restore/2:Breaking changes (v0.19 overhaul)
Spelled out in full in guides/upgrading/v0.19.md:
Backpex.ThemeSelectorPlugremoved — theme is populated byBackpex.InitAssigns.@current_themeinstead of@theme.theme_selectorcomponent takescurrent_theme.app_shellcomponent takessidebar_open.BackpexSidebarhook replacesBackpexSidebarSections.With no
:backpex, Backpex.Preferencesconfig, every key routes to the Session adapter — this matches the zero-config default and keeps behavior stable for apps that don't opt into a custom adapter.Migration — opting into a DB-backed adapter
Backpex.Preferences.Adapteragainst your table (two complete recipes in the guide).ctx.assigns.current_scopefirst.UserAuth.renew_session/2, add theBackpex.Preferences.Session.preserve/restorepair (see above).JavaScript API
The compiled bundle exports
BackpexPreferencesas a named export alongsideHooks:Mirror keys are namespaced under
backpex.prefs.*insessionStorage. The mirror is per-tab (by design) — for cross-tab consistency, use the PubSub cross-tab sync on the server side. Built-in_sidebar.jshook usesmirror: 'session';_theme_selector.jsintentionally does not (theme UI lives outside the LiveView tree).Writes that can't be persisted from a LiveView context (for example
put/4called on a socket when the adapter needs to set a session cookie) fall back to apush_event/3with thebackpex:set_preferencename; the hook inassets/js/hooks/_preferences.jsforwards them toPOST /backpex_preferences.Observability
Every error path in the dispatcher logs a
Logger.warningwith the adapter module, key, and reason. Rescue points in the identity resolver log the resolver MFA. No telemetry events yet — surface can be added in a follow-up if operators need structured metrics.Testing
mix lint: credo clean,mix format --check-formattedclean,mix compile --warnings-as-errorsclean.End-to-end LiveView integration coverage for
persist: [:order, :filters, :columns]lives atdemo/test/demo_web/live/preferences_persistence_test.exs. MountsDemoWeb.PostLive, triggers sort / filter / column-toggle interactions, and asserts the matchingpush_eventfires with the canonical key — the emitter and the test pull from the sameBackpex.Preferences.KeysandBackpex.Preferences.LiveView.event_name/0.Downstream apps writing their own ExUnit tests can
import Backpex.Testto getlive_resource_index/3(follows the default-filter redirect transparently) andput_preference/3(seed a preference into the conn before mount).Test plan
Session-adapter preferences (default config):
live_redirectlink → sidebar stays as the user left it (no snap-back to the stale session snapshot — sessionStorage mirror covers this).<html data-theme>reflects the choice.live_redirectto another resource → section stays as the user left it.POST /backpex_preferencesreturning{ok: true}.Opt-in
persist:on a demo LiveResource (e.g.DemoWeb.PostLiveat/admin/posts):DemoWeb.PostLiveis already configured withpersist: [:order, :filters, :columns].:defaultfilters).persist:, the same actions reset on reload (proves the flag gates persistence).Cross-adapter routing:
resource.*to an ETS-backed test adapter; re-run the opt-in checks; writes land in ETS and the session cookie stays empty for resource keys.{fn k -> String.ends_with?(k, \":columns\") end, SessionAdapter, []}) above{\"resource.*\", EctoAdapter, ...}→:columnswrites land in the session,:order/:filterswrites land in Ecto.Session-renewal preservation:
Backpex.Preferences.Session.preserve/restoreinUserAuth.renew_session/2— log in, toggle theme, log out, log back in → theme preserved (session-backed prefs survive the renewal).Identity resolver:
ctx.assigns.current_scope.user.idworks whenBackpex.InitAssignsis attached after the app's authon_mounthook — writes route correctly to the per-user adapter row.:unidentified(viactx.assignsbeing empty) when called from an anonymous controller context — reads return:default, writes return{ok: false, error: %{reason: :unidentified}}.Key validation (opt-in):
config :backpex, Backpex.Preferences, validate_keys: :logset inconfig/dev.exs, callingBackpex.Preferences.get(session, \"globl.theme\")logs a warning naming the unknown-prefix key; the call still returns the default.validate_keys: trueinconfig/test.exs, the same call raisesArgumentErrornaming the offending key and listingknown_prefixes/0.Cross-tab sync (opt-in PubSub):
pubsub: [server: MyApp.PubSub]configured, open two browser tabs as the same user; toggle theme in tab A; tab B'sLiveView.handle_info/2receives{:backpex_preference_changed, %{key: \"global.theme\", value: new_theme, source: :controller}}.Logger.warninglogged, no crash.Error paths:
200 {ok: false, error: %{key: _, reason: :unidentified}}, no exception.{ok: false, error: %{key, reason}}; subsequent entries in the batch are short-circuited (not dispatched). Earlier successful entries may already be committed.Logger.warningcaptured; response is{ok: false, error: %{reason: {:exception, _}}}.Stacked on
Stacked on top of PR #1748 (
feature/collapsible-sidebar). Retargets todeveloponce that merges.